Skip to content

fix(0.6.0): establish explicit runtime lifecycle ownership - #226

Merged
GionaGranchelli merged 12 commits into
masterfrom
fix/0.6.0-runtime-lifecycle-ownership
Aug 12, 2026
Merged

fix(0.6.0): establish explicit runtime lifecycle ownership#226
GionaGranchelli merged 12 commits into
masterfrom
fix/0.6.0-runtime-lifecycle-ownership

Conversation

@GionaGranchelli

@GionaGranchelli GionaGranchelli commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

Implements Epic 1.3 — Runtime Lifecycle Ownership (docs/ROADMAP-0.6.0.md).

Invariant: every runtime-created engine has exactly one reachable lifecycle owner, and closing that owner
deterministically prevents further work and terminates TramAI-owned work.

Critical defect fixed: Tramai.create() created a fresh unreachable TramaiEngine on every call;
Tramai.runtime() created an independent second engine; Spring @AiService beans could therefore produce
several hidden engines with no lifecycle owner.

What changed

  • Tramai (standalone): now AutoCloseable, owns ONE lazily-created TramaiRuntime (→ one engine) shared
    by all create()/runtime() calls. Lifecycle state lives in class-body fields (lifecycleLock,
    ownedRuntime, closed) so the published JVM constructor descriptor is unchanged. close() is synchronized
    and idempotent. After close, create()/runtime() fail fast with a fixed IllegalStateException("Tramai runtime is closed").
  • TramaiEngine: close() cancels once and awaits engine-hierarchy termination (self-close safe via a
    thread marker). Proxies check a closed flag at the invocation seam before provider execution. Suspend
    bridges launch as children of the caller's job (preserving parent-cancellation propagation) while the engine
    tracks launched invocation jobs and cancels them on close. The caller continuation is resumed exactly
    once
    even when close() cancels a job before the dispatcher starts it.
  • SovereignTramai: create()/runtime() route through the delegate's owned runtime (no hidden engines);
    runtime() returns a cached wrapper; SovereignTramai is AutoCloseable, closing the delegate.
  • Spring: @Bean(destroyMethod = "close") — context destruction closes the shared runtime; all @AiService
    factory beans share the one owned engine.
  • Resource ownership rule (documented): TramAI closes only resources it creates; externally supplied
    providers, stores, clients, executors, observers remain caller-owned unless their API transfers ownership.

Verification

  • Full ./gradlew test --rerun-tasks green (all modules).
  • verifyCancellationSafety PASSED (no new findings).
  • verifyPr -PchangeClass=public-api PASSED (change policy + maintainability baseline).
  • apiDump/apiCheck green — additive only: Tramai/SovereignTramai gain AutoCloseable/close();
    all existing constructor descriptors byte-identical.

Key tests

  • Two create() calls share one runtime lifecycle; runtime() returns the same runtime
  • Concurrent create() creates only one engine (8 threads × 50)
  • create() racing with close() cannot resurrect the runtime (50 iterations)
  • Repeated close() harmless; close-before-first-use rejects
  • Old proxy invoked after close fails before provider executes (provider requests empty)
  • In-flight suspend invocation terminates on close
  • Close racing a fast suspend invocation never leaves work against a closed engine (100 iterations:
    provider-start timestamp must precede close-completion on any success)
  • Self-close from an owned coroutine does not deadlock
  • Spring context destruction closes the runtime; multiple AI-service beans all fail after close (proves no hidden engines)
  • SovereignTramai shares one owned engine; runtime() returns the same wrapper; close propagates
  • Externally supplied provider is NOT closed

Fix rounds (review findings addressed)

Round Head Finding → Fix
1 wave-1 commit agy P1-1: add-after-launch TOCTOU → synchronized launch+add with in-lock closed re-check; P2-1: cancel (not join) caller-parented jobs; P2-2: cached SovereignTramaiRuntime + identity test; P1-1 regression: exactly-once resume when a tracked job is cancelled pre-start (continuation freeze); race stress test with timestamp ordering; Spring multi-bean shared-runtime test
2 round-2 commits P2-3: blocking invocation racing close never delivers a result from a closed engine (post-runBlocking closed re-check + test); P3-3: CHANGELOG entry incl. AutoCloseable supertype note
3 round-3 commit Independent review P1: resumeApproval/registerService unguarded → fail fast on closed engine (+test); P2-1: suspend block re-checks closed after execute, converts in-flight success to fixed lifecycle error; P2-2: streaming flow body fails fast on collection against closed engine (+test, provider untouched)
4 round-4 commit Review P2: mid-collection close left a live stream delivering chunks after close → every emitted chunk gated on engine-open (emitWhileOpen), deterministic termination within one chunk latency (+test: second chunk never delivered after close). P3s accepted: in-flight resumeApproval delivers its committed result (failing post-hoc would orphan the consumed continuation); sovereign lazy wrapper returns inert-but-throwing wrapper post-close
5 round-5 commit Giona review P1s — output suppression ≠ work termination: engine now owns an internal lifecycleJob/lifecycleScope; caller-supplied job/scope never cancelled/joined (caller-supplied-job close deadlock fixed); blocking calls run as lifecycleJob children (close terminates + joins, waits for NonCancellable cleanup); suspend invocations run on the engine's own dispatcher with caller Job retained (parent cancellation preserved; interceptor stripped so close-join can't deadlock a single-threaded caller); streaming collections run in lifecycleScope via a channel bridge (close cancels the collection job and waits for provider cleanup); close() cancels AND joins tracked invocation jobs (cancellation ≠ termination); lifecycleScope has a CoroutineExceptionHandler so orphaned failures log instead of leaking. P2-1 vacuous external-provider test fixed (forces engine creation); Epic 1.3 marked complete in roadmap. Regressions: blocking long-suspension cancelled+joined; streaming suspended-indefinitely cancelled+cleaned-up; caller-supplied-job/scope close no deadlock

Scope notes

  • Lifecycle only — Epic 1.4 (network boundary) and Epic 2.1 (EngineComponents) intentionally excluded.
  • .hermes/plans/*.md are working notes, not committed.

Epic 1.3 (Runtime Lifecycle Ownership): every runtime-created engine has
exactly one reachable lifecycle owner; closing it deterministically
prevents further work and terminates TramAI-owned work.

- Tramai now owns ONE lazily-created TramaiRuntime (one engine) shared by
  all create()/runtime() calls; lifecycle state lives in class-body fields
  so the published JVM constructor descriptor is unchanged. Tramai is now
  AutoCloseable; close() is idempotent and synchronized; after close,
  create()/runtime() fail fast with a fixed IllegalStateException.
- Engine proxies fail after close BEFORE provider execution (closed flag
  checked at the invocation handler seam).
- TramaiEngine.close() cancels once and joins (except from its own
  coroutines, avoiding self-close deadlock), and explicitly cancels tracked
  suspend-invocation jobs: suspend bridges launch as children of the CALLER
  job (preserving parent-cancellation propagation) while the engine tracks
  them so close() owns in-flight work.
- SovereignTramai propagates the same ownership: create()/runtime() share
  the delegate's owned runtime; SovereignTramai is AutoCloseable closing
  the delegate.
- Spring: the Tramai bean uses destroyMethod = close so context destruction
  closes the shared runtime; multiple @aiservice beans share one engine.
- Resource ownership rule documented: TramAI closes only resources it
  creates; externally supplied providers/stores/clients/observers remain
  caller-owned.
- Tests: shared lifecycle, single engine under concurrency, no resurrection
  after close, idempotent close, proxy-after-close fails before provider,
  in-flight suspend terminates on close, self-close no deadlock, Spring
  destruction + shared-engine, sovereign equivalence, external deps not
  closed. api dumps updated additively (AutoCloseable only).
Copilot AI lite review requested due to automatic review settings August 10, 2026 13:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Establishes explicit runtime lifecycle ownership so a single Tramai instance deterministically owns (and can close) exactly one lazily-created runtime/engine, with corresponding changes in engine shutdown semantics, sovereign delegation, Spring bean lifecycle wiring, and new tests validating the ownership/closure invariants.

Changes:

  • Make Tramai and SovereignTramai AutoCloseable, with Tramai owning a single shared TramaiRuntime across create()/runtime() and failing fast after close.
  • Strengthen TramaiEngine shutdown behavior with a closed flag, invocation-time closed checks, and tracking/cancellation of in-flight suspend invocations on close.
  • Wire Spring to close the shared Tramai bean at context shutdown and add coverage across standalone/engine/sovereign/spring.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tramai-standalone/src/test/kotlin/dev/tramai/standalone/TramaiTest.kt Adds lifecycle ownership and close-behavior tests for standalone Tramai.
tramai-standalone/src/main/kotlin/dev/tramai/standalone/Tramai.kt Makes Tramai AutoCloseable and enforces a single owned runtime with synchronized lifecycle state.
tramai-standalone/api/tramai-standalone.api Public API update reflecting AutoCloseable + close().
tramai-spring/src/test/kotlin/dev/tramai/spring/TramaiAutoConfigurationTest.kt Adds tests ensuring Spring context destruction closes the shared runtime and invalidates proxies.
tramai-spring/src/main/kotlin/dev/tramai/spring/TramaiAutoConfiguration.kt Configures the Tramai bean with destroyMethod = "close".
tramai-sovereign/src/test/kotlin/dev/tramai/sovereign/SovereignTramaiTest.kt Adds tests for shared engine ownership and close propagation in sovereign mode.
tramai-sovereign/src/main/kotlin/dev/tramai/sovereign/SovereignTramai.kt Makes SovereignTramai AutoCloseable and delegates close() to standalone Tramai.
tramai-sovereign/api/tramai-sovereign.api Public API update reflecting AutoCloseable + close().
tramai-engine/src/test/kotlin/dev/tramai/engine/TramaiEngineTest.kt Adds tests for post-close proxy failure, in-flight cancellation on close, and self-close non-deadlock.
tramai-engine/src/main/kotlin/dev/tramai/engine/TramaiEngine.kt Implements closed-state gating, suspend-invocation job tracking, and close-time cancellation/join behavior.
docs/modules/tramai-engine.md Updates engine API reference text for close().

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tramai-engine/src/main/kotlin/dev/tramai/engine/TramaiEngine.kt Outdated
Comment thread docs/modules/tramai-engine.md Outdated
…n runtime cache, race coverage

agy round-1 review fixes (PR #226):

- Suspend invokeSuspend now resumes the caller continuation exactly once
  even when close() cancels the tracked job BEFORE the dispatcher starts
  it: the block records its outcome before resuming, and invokeOnCompletion
  resumes with a cancellation when the block never ran — otherwise the
  caller's suspension would freeze forever.
- close() cancels tracked caller-parented invocation jobs but never joins
  them: their completion is dispatched on the CALLER's dispatcher, which
  may be blocked waiting on this very close() (joining would deadlock).
  The engine scope job is still cancelled-and-joined.
- SovereignTramai.runtime() caches the wrapper around the delegate's single
  owned runtime (repeated calls return the same instance; identity test).
- New tests: close racing a fast suspend invocation never leaves work
  against a closed engine (100 iterations; provider-start vs close-complete
  ordering asserted); multiple Spring AI-service beans share one runtime and
  all fail after context close; sovereign runtime identity.
…gelog

agy round-1 P2-3 + P3-3 (PR #226):

- Blocking proxy invocations re-check the closed flag after the caller-owned
  runBlocking completes, so a call that raced close() surfaces the fixed
  'Tramai runtime is closed' IllegalStateException instead of delivering a
  result computed against an already-closed engine. Test added.
- CHANGELOG entry for PR #226 including the AutoCloseable supertype note
  (source-compatible; affects compiled negative instanceof checks).
…ming flows

Independent review findings (PR #226):

- P1: resumeApproval and registerService ran provider work deterministically
  after close() — the closed guard existed only on create(), the proxy invoke
  seam, and the suspend launch. Both entry points now fail fast with the fixed
  'Tramai runtime is closed' IllegalStateException.
- P2-1: the suspend invocation block could deliver a success computed against
  a closed engine (caller-parented job, not joined by close). The launched
  block now re-checks the closed flag after execute() and converts a success
  into the fixed lifecycle error, mirroring the blocking path.
- P2-2: streaming flows escaped close() entirely — a flow obtained before
  close() and collected after ran the full provider pipeline. The flow body
  now fails fast on collection against a closed engine.
- Tests: registerService/resumeApproval fail fast on a closed engine; streaming
  flow collected after close fails before provider executes (provider untouched).
Round-3 review P2 (PR #226): mid-collection close left a live provider
stream delivering chunks after close() — the flow-body start guard only
covered collection-after-close, and the collector's job is not cancelled by
close(), so cooperative cancellation never fired. Every emitted chunk is now
gated on the engine being open (emitWhileOpen), so a cold flow being
collected at close() time terminates deterministically within one chunk
latency with the fixed 'Tramai runtime is closed' error.

Test: mid-collection close terminates an in-flight stream (first chunk
delivered, close, gate release -> second chunk never delivered).
…l engine-initiated work

Addresses Giona's round-5 review (PR #226): output suppression is not work
termination; close() must not return while engine-created invocations are
still active.

- The engine now owns an internal lifecycleJob/lifecycleScope. The caller-
  supplied job/scope constructor parameters are NEVER cancelled or joined
  (fixes the caller-supplied-job close() deadlock): close() cancels and
  joins lifecycleJob plus every tracked invocation.
- Blocking calls run as children of lifecycleJob (runBlocking(lifecycleJob)),
  so close() terminates a blocking provider still executing and waits for
  its NonCancellable cleanup before returning.
- Suspend invocations run on the engine's own dispatcher (caller's Job
  element retained for parent-cancellation propagation; the interceptor is
  stripped so close() joining cannot deadlock a single-threaded caller
  loop). Parent-cancellation contract tests still pass.
- Streaming collections run in lifecycleScope and bridge chunks to the
  collector's emit through a channel; close() cancels the collection job and
  waits for provider cleanup. Per-chunk closed gate retained.
- close() now joins tracked invocation jobs (cancellation request is not
  termination; NonCancellable cleanup must complete before close returns).
- lifecycleScope carries a CoroutineExceptionHandler so orphaned background
  work failures log instead of leaking onto a shared global handler.
- Tests: blocking long-suspension cancelled+joined by close; streaming
  collection suspended indefinitely cancelled+cleaned up; close with
  caller-supplied job/scope does not deadlock; external-provider test now
  forces engine creation (was vacuous); observer fixtures thread-safe for
  Default-dispatcher invocations; close-race test suspends instead of
  blocking Thread.join. Roadmap Epic 1.3 marked complete.
Copilot thread r3749862916: 'waits for externally initiated shutdown' was
ambiguous. State exactly what close() does: cancels and joins engine-owned
work (blocking, suspend, streaming) and never touches the caller-supplied
job/scope constructor parameters.
@GionaGranchelli

Copy link
Copy Markdown
Owner Author

Round-5 rework landed — all P1/P2/P3 items addressed (review this at head e4cdcae5+, not 5feef47f)

Your review was against 5feef47f; round-5 (e4cdcae5, pushed before this comment) implements the recommended root fix. Per-point:

P1-1A — Blocking calls aren't tracked

Now runBlocking(lifecycleJob + engineThreadMarker.asContextElement(true)) { execute(...) } — the blocking call is a child of the engine's internal lifecycleJob. close() cancels lifecycleJob and joins it, so a blocking provider in a 30s suspension is cancelled and its NonCancellable cleanup runs before close() returns.
Regression: blocking invocation in long suspension is cancelled and joined by close (provider enters awaitCancellation(), close() from another thread, asserts cleanup completed within close()).

P1-1B — Streaming collections aren't owned

Provider collection now runs in lifecycleScope.launch (a child of lifecycleJob), chunks bridged to the collector's emit via an UNLIMITED Channel (emit stays in the collector's coroutine — SafeCollector invariant). close() cancels lifecycleJob → the collection job is cancelled → provider finally executes → close() joins lifecycleJob and waits. The per-chunk emitWhileOpen gate is retained as belt-and-suspenders; the TOCTOU you flagged is closed by ownership, not by the boolean check.
Regressions: streaming collection suspended indefinitely is cancelled and cleaned up by close (provider emits first, awaitCancellation() forever, close() → cleanup completes within close), plus the existing mid-collection close terminates an in-flight stream.

P1-1C — Tracked suspend calls cancelled but not joined

close() now joins every tracked invocation: runBlocking { lifecycleJob.join(); tracked.forEach { it.join() } } — cancellation is a request; NonCancellable cleanup must complete before close() returns. The self-close marker (engineThreadMarker) still skips the join when close() is called from an engine-owned coroutine (can't join your own job).

P1-2 — Same-job deadlock with public constructor API

Fixed by the ownership distinction, not ThreadLocal: close() touches only lifecycleJob + tracked jobs — the caller-supplied job/scope ctor params are never cancelled or joined (they remain for ABI). TramaiEngine(provider, job = coroutineContext.job, scope = this); close() no longer deadlocks.
Regression: close does not deadlock when caller supplied its own job and scope.

P2-1 — Vacuous external-provider test

Now forces tramai.runtime() before close so the assertion runs against a real engine.

P3 — Roadmap

docs/ROADMAP-0.6.0.md marks Epic 1.3 ✅ Complete — PR #226 at line 359, matching Epics 1.1/1.2.

Verification: full ./gradlew test --rerun-tasks green (2m45s), verifyPr PASSED (41 files), maintainability baseline PASSED, verifyCancellationSafety PASSED, apiCheck additive (AutoCloseable only). Suspend invocations run on the engine's own dispatcher (caller Job retained via minusKey(ContinuationInterceptor) only) so the parent-cancellation contract tests still pass.

Requesting re-review at the new head.

…se race, safe logging

Addresses Giona's round-6 review of the streaming lifecycle bridge.

- Channel.UNLIMITED -> Channel.RENDEZVOUS: a slow collector now blocks the
  provider instead of letting it race ahead into unbounded buffering
  (backpressure semantics preserved; take(1)/slow-collector behavior
  unchanged). Regression: 'streaming bridge preserves backpressure when the
  collector is slow' proves the provider cannot emit chunk 2..N while the
  collector is blocked on chunk 1.
- Channel close now depends on JOB completion, not on the collection body
  having started: collectJob.invokeOnCompletion { chunks.close(cause) }.
  If close() cancels lifecycleJob after the flow's open check but before the
  launched body runs, the collector terminates instead of hanging forever on
  receive. Regression: 'stream start racing close never hangs the collector'
  (200 iterations of the admission race, each bounded).
- Streaming failures are captured into collectFailure and surfaced to the
  collector via the channel drain instead of being rethrown: an arbitrary
  (possibly sensitive, externally supplied) throwable no longer reaches the
  lifecycle CoroutineExceptionHandler and the normal logger. The handler now
  logs fixed safe metadata (exception type name only), never the raw
  throwable — consistent with Epic 1.2 safe-error-boundary work.
- Fixed stale close() comment: invocation jobs run on the engine's own
  dispatcher (caller ContinuationInterceptor stripped), not the caller's.
@GionaGranchelli

Copy link
Copy Markdown
Owner Author

Round-6 rework landed — P1/P2/P3 resolved at head af5e1a4a

All four round-6 findings addressed:

P1 — stream-start vs close race can hang the caller

Channel termination now depends on job completion, not on the collection body having started: collectJob.invokeOnCompletion { cause -> chunks.close(cause) } is registered immediately after the launch. If close() cancels lifecycleJob after the flow's open check but before the launched body runs, the invokeOnCompletion still fires and closes the channel — the collector terminates instead of hanging forever on receive. (Same pre-start protection pattern you already approved for suspend invocations.)
Regression: stream start racing close never hangs the collector — 200 iterations of the exact admission race, each bounded by withTimeout(5s).

P2 — Channel.UNLIMITED destroys backpressure

Switched to Channel.RENDEZVOUS. A slow collector now blocks the provider's next send instead of letting it buffer unboundedly; take(1) and slow-collector semantics match a direct flow connection.
Regression: streaming bridge preserves backpressure when the collector is slow — collector blocks after chunk 1, asserts the provider emitted exactly 1 chunk (UNLIMITED would have let it race ahead).

P2 — lifecycle handler could log raw user-supplied exceptions

Two-part fix: (1) streaming failures are now captured into collectFailure and surfaced to the collector through the channel drain — the collection job no longer rethrows, so arbitrary throwables (e.g. an OperationInterceptor throwing RuntimeException("customer-token=SECRET")) never reach the CoroutineExceptionHandler; (2) the handler itself logs fixed safe metadata only — the exception type's qualified name — never the raw throwable, consistent with Epic 1.2 safe-error-boundary work.

P3 — stale dispatcher comment

close()'s comment now states the implementation's actual behavior: invocation jobs run on the engine's own dispatcher (caller ContinuationInterceptor stripped at launch), so joining is safe as long as close() isn't called from a coroutine dispatched on that same engine dispatcher.

Verification: full ./gradlew test --rerun-tasks green (2m33s, 212 tasks), TramaiEngineTest green including both new regressions, apiCheck additive. Requesting re-review.

…close safe

Addresses Giona's round-7 review.

- The engine-thread marker now lives on lifecycleScope itself
  (engineThreadMarker.asContextElement(true) in the scope context), so EVERY
  engine-owned child — including the streaming collection job — carries the
  self-close protection automatically. Previously only blocking and suspend
  paths had it explicitly, so engine.close() called from inside a streaming
  provider/interceptor/observer would self-join forever (lifecycleJob.cancel
  -> join of a job blocked inside close()).
- Regression: 'self close from streaming owned coroutine does not deadlock'
  (provider flow calls engine.close() mid-collection; withTimeout(2s) proves
  termination).
- Roadmap: Epic 1.3 gains the leak-test evidence matrix (task 6): engine
  jobs, worker jobs, subprocesses, HTTP response streams, shutdown hooks each
  mapped to their concrete tests.
@GionaGranchelli

Copy link
Copy Markdown
Owner Author

Round-7 rework landed — P1/P2/P3 addressed at head 3f1c4f8a

P1 — streaming self-close deadlock (merge blocker)

The engine-thread marker now lives on lifecycleScope itself: CoroutineScope(lifecycleJob + Dispatchers.Default + engineThreadMarker.asContextElement(true) + exceptionHandler). Every engine-owned child — including the streaming collection job — inherits the self-close protection automatically, so engine.close() called from inside a streaming provider/interceptor/observer skips the join and cannot self-deadlock. This encodes ownership once at the scope level exactly as you recommended (stronger than decorating individual launches, and it covers future lifecycle tasks for free). The blocking path keeps its explicit marker (runBlocking isn't scope-launched); the suspend launch's explicit marker is now redundant but harmless.

Regression added, exactly as specified: self close from streaming owned coroutine does not deadlock — provider flow calls engine.close() mid-collection; withTimeout(2_000) proves termination.

P2 — roadmap leak-test evidence matrix

Epic 1.3 now documents the matrix mapping all five roadmap task-6 requirements to concrete tests:

Requirement Proof
Engine jobs #226 lifecycle tests (blocking/suspend/streaming join, self-close both variants, close-race, caller-job)
Worker jobs tramai-orchestration TramaiWorkerTest shutdown/cancellation
Subprocesses SubprocessCancellationContractTest (#216/#221)
HTTP response streams #226 streaming lifecycle tests + springboot example E2E
Shutdown hooks Spring destroyMethod + context-shutdown tests + close idempotency

P3 — timing-based tests

The new lifecycle regressions use withTimeout bounds and CompletableDeferred gates, not scheduler-time assumptions; the one delay(300) (backpressure test) is a deliberate negative-observation window for a broken UNLIMITED bridge, not a correctness gate.

Verification: full ./gradlew test verifyPr --rerun-tasks green (3m, 212 tasks), apiCheck additive, engine suite green including the new regression. Requesting re-review at the new head.

…t stream evidence

Addresses Giona's round-8 review (P2/P3; no P1 remains).

- New regression 'close deregisters the JVM shutdown hook and retains no
  reference' (TramaiWorkerTest): proves start() registers a hook and close()
  -> shutdown() removes it (private field reflection: non-null -> null).
- Roadmap HTTP-stream evidence row now cites the provider-level InputStream
  cleanup tests in OpenAiProviderTest (close after DONE, malformed chunk,
  collector stop after first token, mid-stream I/O failure) instead of only
  the engine Flow bridge tests.
- Timing determinism: the suspended-stream cleanup test's delay(200) is now a
  firstChunkDelivered CompletableDeferred gate; the backpressure test's
  delay(300) is replaced with a structural proof (provider ATTEMPTS chunk 2
  then stalls at the rendezvous send: attempted==2, delivered==1).
@GionaGranchelli

Copy link
Copy Markdown
Owner Author

Commit-location clarification + round-8 cleanup

Head reconciliation: the round-7 commit is 9c0a736c — already on this branch and the PR head (your gh check confirms identical/ahead_by 0). My previous comment's "at head 3f1c4f8a" was a typo in the comment body, not a missing push. The scope-level engine-thread marker (9c0a736c) is what you reviewed. No phantom push — sorry for the hunt.

Round-8 items, all addressed at the new head:

P2 — shutdown-hook leak proof

New regression in tramai-orchestration TramaiWorkerTest: close deregisters the JVM shutdown hook and retains no reference — reflects the private shutdownHook field: non-null after start() (hook registered), null after close() (deregistered and no reference retained).

P3 — HTTP-stream evidence row

Roadmap now cites the actual provider-level InputStream cleanup tests in tramai-openai OpenAiProviderTest: stream closes response body after done marker, stream closes response body after malformed chunk, stream closes response body when collector stops after first token, mid stream io failure is retryable sanitized and observed — plus the #226 engine bridge tests and the springboot example E2E.

P3 — timing synchronization

  • The suspended-stream cleanup test's delay(200) admission assumption is now a deterministic firstChunkDelivered CompletableDeferred gate.
  • The backpressure test's delay(300) is gone: it now proves backpressure structurally — the provider ATTEMPTS chunk 2 then STALLS at the rendezvous send (attempted == 2, delivered == 1), no sleep involved.

Verification: engine + orchestration suites green (incl. new tests), full test verifyPr running. Requesting re-review at the new head.

…just reference clearing

Addresses Giona's round-8 follow-up (P2): the previous regression proved
close() nulls the worker's shutdownHook field but NOT that the JVM registry
was actually deregistered — a mutation removing Runtime.removeShutdownHook
would still pass.

Strengthened: after close(), Runtime.removeShutdownHook(hookAfterStart) must
return FALSE (hook already deregistered). Verified mutation-sensitive: with
the production deregistration commented out, the test FAILS; restored,
it passes. close() moved into try/finally so an assertion failure can never
leave a hook registered in the test JVM.

Roadmap shutdown-hook row now cites this test (JVM-level deregistration +
no retained Thread reference) instead of only the indirect Spring/standalone
evidence.
@GionaGranchelli

Copy link
Copy Markdown
Owner Author

Round-8 follow-up landed — shutdown-hook deregistration now mutation-proven (head 4c1d9a3e)

You were right: the previous regression proved reference clearing, not JVM deregistration. Fixed exactly as you specified.

Strengthened regression (TramaiWorkerTest)

After worker.close():

  • hookField.get(worker) is null (no retained Thread reference) ✅
  • Runtime.getRuntime().removeShutdownHook(requireNotNull(hookAfterStart)) returns false — the hook is already absent from the JVM registry ✅
  • worker.close() wrapped in try/finally: an earlier assertion failure can never leave a hook registered in the test JVM

Mutation-sensitivity verified

I actually performed the mutation you described (commented out Runtime.getRuntime().removeShutdownHook(hook) in TramaiWorker.shutdown, kept shutdownHook = null): the strengthened regression FAILED as designed (BUILD FAILED). Restored the production call — the test passes again. So the regression now fails when the real deregistration statement disappears, which is exactly the leak Task 6 must catch.

Roadmap row corrected

The shutdown-hooks evidence row now cites this test first: close deregisters the JVM shutdown hook and retains no reference — proves the registered hook is absent from Runtime after close (removeShutdownHook returns false) and the worker retains no Thread reference — plus the Spring destroyMethod/context-shutdown and standalone idempotency tests as secondary evidence.

Verification: orchestration suite green (46s), full test verifyPr green (3m9s, EXIT 0), production TramaiWorker.kt unchanged in this commit (test + roadmap only). CI re-running on the new head.

Also noting for the record: the earlier "3f1c4f8a" was a typo in my comment body — the round-7 commit has always been 9c0a736c, on this branch.

Requesting re-review — expecting this to be the close-out.

@GionaGranchelli
GionaGranchelli merged commit 44ba395 into master Aug 12, 2026
5 checks passed
GionaGranchelli added a commit that referenced this pull request Aug 12, 2026
…— review round 1

P2: ExecutionComponents no longer carries the caller-supplied job/scope
compatibility parameters; public ctor descriptors preserved. Engine work
parents exclusively to the internally owned lifecycleJob/lifecycleScope
(PR #226); the compat scope was verified dead on every launch path.
Also: group KDocs reworded (caller vs engine ownership), ROADMAP Epic 2.1
marked complete, CHANGELOG + PR wording tightened.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants